跳到主要内容

SpringSecurity 原理篇 认证流程源码解析

Spring Security 采用 AOP,基于 Servlet 过滤器实现的安全框架。它提供了完善的认证机构和授权功能,而在做系统的时候,一般做的第一个模块就是认证与授权模块,因为这是一个系统的入口,也是一个系统最重要最基础的一环,在认证与授权服务设计搭建好了之后,剩下的模块才得以安全访问。

Spring Security 主要功能如下

  • 认证
  • 授权
  • 攻击防护

整个 Spring Security 的类图

这篇笔记主要讲 Spring Security 的认证流程,下一篇笔记才是鉴权流程

常用的 Http 认证授权技术

1、基于表单的认证(Cookie & Session):基于表单的认证并不是在 HTTP 协议中定义的,而是服务器自己实现的认证方式,安全程度取决于实现程度。一般用 Cookie 来管理 Session 会话,是最常用的认证方式之一。它的安全程度取决于服务器的实现程度,客户端在Cookie中携带认证信息,服务器解析并返回结果。

2、基于JWT(Json Web Token)的认证:App和服务端常用的认证方式,用户ID和密码传输到服务器上验证,服务器验证通过以后生成加密的JWT Token返回给客户端,客户端再发起请求时携带返回的Token进行认证。(多了个防篡改)

3、Http Basic 认证:最早的 Http 认证方式,用户 ID 和密码以分号连接,经过 Base64 编码后存储到 Authorization 字段,发送到服务端进行认证 ;用户 ID/密码 以明文形式暴露在网络上,安全性较差。(如果没有使用 SSL/TLS 这样的传输层安全的协议,那么以明文传输的密钥和口令很容易被拦截)

4、Http Digest 认证:在 HttpBasic 的基础上,进行了一些安全性的改造,用户ID, 密码 , 服务器/客户端随机数,域,请求信息,经过 MD5 加密后存储到 Authorization 字段,发送到服务端进行认证;密码经过 MD5 加密,安全性比 Basic 略高。

5、其他认证方式(Oauth 认证,单点登陆,HMAC 认证):通过特定的加密字段和加密流程,对客户端和服务端的信息进行加密生成认证字段,放在 Authorization 或者是消息体里来实现客户信息的认证

主要 jar 包功能介绍

Spring Security 主要 jar 包功能介绍

spring-security-core.jar 核心包,任何 Spring Security 功能都需要此包。 spring-security-web.jar web 工程必备,包含过滤器和相关的 Web 安全基础结构代码。 spring-security-config.jar 用于解析 xml 配置文件,用到 Spring Security 的 xml 配置文件的就要用到此包。 spring-security-taglibs.jar Spring Security 提供的动态标签库,jsp 页面可以用。

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>

Security 认证流程

参考资料 Spring Security(一)--Architecture Overview(这是一系列教程) 参考资料 SpringSecurity+JWT认证流程解析

登录认证(Authentication)和访问授权(Authorization)

认证流程如下图所示

这里的 AuthenticationFilter 采用的是责任链设计模式(请求层层上报,直到有人解决为止),一个 web 请求会经过一条过滤器链,在经过过滤器链的过程中会完成认证与授权,如果中间发现这条请求未认证或者未授权,会根据被保护 API 的权限去抛出异常,然后由异常处理器去处理这些异常。

过滤链如下图所示

注:这里 Security 提供两种过滤器类:

UsernamePasswordAuthenticationFilter 表示表单登陆过滤器 BasicAuthenticationFilter 表示 httpBasic 方式登陆过滤器

如上图,一个请求想要访问到 API 就会以从左到右的形式经过蓝线框框里面的过滤器,其中绿色部分是负责认证的过滤器,蓝色部分负责异常处理,橙色部分则是负责授权。

不过上述的两个过滤器是 Spring Security 对 form 表单认证和 Basic 认证内置的两个 Filter,而 JWT 认证方式用不上。

不过这两个过滤器有两个自带的叫 formLogin 和 httpBasic 的配置项

@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.and()
.formLogin() // formLogin 对应着 form 表单认证方式,即 UsernamePasswordAuthenticationFilter
.and()
.httpBasic(); // httpBasic 对应着 Basic 认证方式,即 BasicAuthenticationFilter
}

换言之,配置了这两种认证方式,过滤器链中才会加入它们,否则它们是不会被加到过滤器链中去的。

因为 Spring Security 自带的过滤器中是没有针对 JWT 的认证方式,所以需要自己写一个 JWT 的认证过滤器,然后放在绿色的位置进行认证工作。

一些重要的组件

  • SecurityContext:上下文对象,Authentication 对象会放在里面。
  • SecurityContextHolder:用于拿到上下文对象的静态工具类。
  • Authentication:认证接口,定义了认证对象的数据形式。
  • AuthenticationManager:用于校验 Authentication,返回一个认证完成后的 Authentication 对象。

这里建议先看下面 “整合上面四个组件认证流程” 了解下各个组件具体是如何执行的,再回来详细的看各个组件的细节

SecurityContext

上下文对象,认证后的数据就放在这里面,接口定义如下:

public interface SecurityContext extends Serializable {
// 获取Authentication对象
Authentication getAuthentication();

// 放入Authentication对象
void setAuthentication(Authentication authentication);
}

这个接口里面只有两个方法,其主要作用就是 get or set Authentication

SecurityContextHolder

用于拿到上下文对象的静态工具类。

SecurityContextHolder 用于存储安全上下文(security context)的信息。当前操作的用户是谁,该用户是否已经被认证,他拥有哪些角色权限… 这些都被保存在 SecurityContextHolder 中。SecurityContextHolder 默认使用 ThreadLocal 策略来存储认证信息。

public class SecurityContextHolder {

public static void clearContext() {
strategy.clearContext();
}

public static SecurityContext getContext() {
return strategy.getContext();
}

public static void setContext(SecurityContext context) {
strategy.setContext(context);
}

}

可以说是 SecurityContext 的工具类,用于 get or set or clear SecurityContext,默认会把数据都存储到当前线程中。

从这个 SecurityContextHolder 取得 UserDetail 的实例:

public static String getLoginAccount() {
// getPrincipal 返回值需要强转为 UserDetail 具体原因看下面的 Authentication那节
return ((UserDetail) SecurityContextHolder
.getContext()
.getAuthentication() // 返回:Authentication
.getPrincipal()) // 这里就是 Authentication 内部保存的 UserDetail 对象
.getUsername();
}

Authentication

认证接口,定义了认证对象的数据形式。

public interface Authentication extends Principal, Serializable {

Collection<? extends GrantedAuthority> getAuthorities();
Object getCredentials();
Object getDetails();

// AuthenticationManager 实现通常会返回一个包含更丰富信息的 Authentication 作为供应用程序使用的主体。
// 许多身份验证提供程序将创建一个 UserDetails 对象作为主体
// 所以如果 AuthenticationManager 使用的是 ProviderManager 则这里返回值需要强转为 UserDetails
Object getPrincipal();

boolean isAuthenticated();
void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException;
}

这几个方法效果如下:

  • getAuthorities: 获取用户权限,一般情况下获取到的是用户的角色信息。
  • getCredentials: 获取证明用户认证的信息,通常情况下获取到的是密码等信息。
  • getDetails: 获取用户的额外信息,(这部分信息可以是我们的用户表中的信息)。
  • getPrincipal: 获取用户身份信息,在未认证的情况下获取到的是用户名,在已认证的情况下获取到的是 UserDetails。
  • isAuthenticated: 获取当前 Authentication 是否已认证。
  • setAuthenticated: 设置当前 Authentication 是否已认证(true or false)。

Authentication 只是定义了一种在 SpringSecurity 进行认证过的数据的数据形式应该是怎么样的,要有权限,要有密码,要有身份信息,要有额外信息。

AuthenticationManager ⭐

public interface AuthenticationManager {
// 认证方法
Authentication authenticate(Authentication authentication)
throws AuthenticationException;
}

AuthenticationManager (接口)是认证相关的核心接口,它定义了一个认证方法,它将一个未认证的 Authentication 传入,返回一个已认证的 Authentication。它的默认实现类是 ProviderManager

注:AuthenticationManager 有多个认证类 AuthenticationManager,ProviderManager ,AuthenticationProvider …

整合上面四个组件认证流程

将这四个部分,串联起来,构成 Spring Security 进行认证的流程:

1、先是一个请求带着身份信息进来,用户名和密码被过滤器获取到,封装成 Authentication,通常情况下是 UsernamePasswordAuthenticationToken 这个实现类。

2、这个 Authentication 经过 AuthenticationManager 的认证(身份管理器负责验证)认证成功后,AuthenticationManager 身份管理器返回一个被填充满了信息的(包括上面提到的权限信息,身份信息,细节信息,但密码通常会被移除)Authentication 实例。

3、SecurityContextHolder 安全上下文容器将上面填充了信息的 Authentication,通过 SecurityContextHolder.getContext().setAuthentication(…) 方法,设置到 SecurityContext 其中。

下面编写一个例子,具体的描述这个流程

public class AuthenticationExample {
private static final AuthenticationManager am = new SampleAuthenticationManager();

public static void main(String[] args) throws Exception {
BufferedReader in = new BufferedReader(new InputStreamReader(System.in));

while(true) {
System.out.println("Please enter your username:");
String name = in.readLine();
System.out.println("Please enter your password:");
String password = in.readLine();

try {
// 1、封装一个 UsernamePasswordAuthenticationToken 对象
Authentication request = new UsernamePasswordAuthenticationToken(name, password);

// 2、经过 AuthenticationManager 的认证,如果认证失败会抛出一个 AuthenticationException 错误
Authentication result = am.authenticate(request);

// 3、将这个认证过的 Authentication 填入 SecurityContext 里面
SecurityContextHolder.getContext().setAuthentication(result);
break;
} catch(AuthenticationException e) {
System.out.println("Authentication failed:" + e.getMessage());
}
}

System.out.println("Successfully authenticated. Security context contains:\n" +
SecurityContextHolder.getContext().getAuthentication());
}
}

// 实现一个简单 AuthenticationManager 用于认证
class SampleAuthenticationManager implements AuthenticationManager {
static final List<GrantedAuthority> AUTHORITIES = new ArrayList<>();

static {
AUTHORITIES.add(new SimpleGrantedAuthority("ROLE_USER"));
}

// 关键认证部分
@Override
public Authentication authenticate(Authentication auth) throws AuthenticationException {

// getCredentials 返回的是密码,这里随便写了,直接用户名和密码一致就算登陆成功
if (auth.getName().equals(auth.getCredentials())) {
// 认证成功返回一个已经认证的 UsernamePasswordAuthenticationToken 的对象,并把这个用户的权限填入
return new UsernamePasswordAuthenticationToken(auth.getName(),
auth.getCredentials(), AUTHORITIES);
}
throw new BadCredentialsException("Bad Credentials");
}
}

输出为:

注意:上述这段代码只是为了让大家了解 Spring Security 的工作流程而写的,不是什么源码。在实际使用中,整个流程会变得更加的复杂,但是基本思想,和上述代码如出一辙。

ProviderManager

ProviderManager 是 AuthenticationManager 的默认实现类,其实很大一部分工具类都是围绕着这个 ProviderManager 实现类来的,由他衍生出来的 AuthenticationProvider 接口

下面来看下只保留了关键认证部分的 ProviderManager 源码:(主要看这里的 providers 的注释)

public class ProviderManager implements AuthenticationManager, MessageSourceAware, InitializingBean {

// 这里维护着一个 AuthenticationProvider 列表,存放多种认证方式,实际上这是委托者模式的应用(Delegate)。
// 也就是说,核心的认证入口始终只有一个:AuthenticationManager
// 例如下面不同的认证方式,对应了三个 AuthenticationProvider:
// 1、用户名 + 密码(UsernamePasswordAuthenticationToken),
// 2、邮箱 + 密码,
// 3、手机号码 + 密码登录
// 在默认策略下,只需要通过一个 AuthenticationProvider 的认证,即可被认为是登录成功。
private List<AuthenticationProvider> providers = Collections.emptyList();


public Authentication authenticate(Authentication authentication)
throws AuthenticationException {
Class<? extends Authentication> toTest = authentication.getClass();
AuthenticationException lastException = null;
Authentication result = null;

// ProviderManager 中的 List(providers),会依照次序去认证,认证成功则立即返回,
// 若认证失败则返回 null,下一个 AuthenticationProvider 会继续尝试认证,如果所有
// 认证器都无法认证成功,则 ProviderManager 会抛出一个 ProviderNotFoundException 异常。
for (AuthenticationProvider provider : getProviders()) {

// 这个 supports 方法用来判断此AuthenticationProvider 是否支持当前的 Authentication 对象
if (!provider.supports(toTest)) {
continue;
}
try {
result = provider.authenticate(authentication);

if (result != null) {
copyDetails(authentication, result);
break;
}
}
...
catch (AuthenticationException e) {
lastException = e;
}
}

// 如果有 Authentication 信息,则直接返回
if (result != null) {
if (eraseCredentialsAfterAuthentication
&& (result instanceof CredentialsContainer)) {
// 移除密码
((CredentialsContainer) result).eraseCredentials();
}
// 发布登录成功事件
eventPublisher.publishAuthenticationSuccess(result);
return result;
}
...

// 执行到此,说明没有认证成功,包装异常信息
if (lastException == null) {
lastException = new ProviderNotFoundException(messages.getMessage(
"ProviderManager.providerNotFound",
new Object[] { toTest.getName() },
"No AuthenticationProvider found for {0}"));
}
prepareException(lastException, authentication);
throw lastException;
}
}

AuthenticationManager 的默认实现类 ProviderManager,可以发现就是它的内部就是通过注册一些 AuthenticationProvider,然后维护一个 List<AuthenticationProvider> 依次对 Authentication 进行比对

把上面的代码整理成图,流程如下所示

ProviderManager 体系类图

这整个 ProviderManager 体系的类图如下

AuthenticationProvider 接口 ⭐

这里 AuthenticationProvider 接口就下面两个实现类

public interface AuthenticationProvider {

// 认证
Authentication authenticate(Authentication authentication)
throws AuthenticationException;

// 这个 supports 方法用来判断此AuthenticationProvider 是否支持当前的 Authentication 对象
boolean supports(Class<?> authentication);
}

就是输入一个凭证,输出一个 Principal(输入输出都是封装在 Authentication 里面的)

而它们有可以有多个类型的认证方式

如何组装这些认证呢?所以这时就需要 AuthenticationManager 上马工作了,当然并不是直接使用它,而是使用它的实现类 ProviderManager 它会执行这里注册进来的每一个 AuthenticationProvider 直到认证通过为止(全部都无法通过表示认证失败)

那这些 AuthenticationProvider 需要做什么工作呢?首先通过 username 取得数据库里面的用户数据,所以这时就可以使用这个 UserDetailService 来取得数据

这个 UserDetailService 会将用户数据填充到 Authentication 里面去

最后的最后,为了让其它的过滤器也能获取到这个已经认证过的 Authentication,Spring Security 会将其存到上下文中(内部是一个 ThreadLocal)

ProviderManager 如何维护 AuthenticationProvider

所以实际原理就是 ProviderManager 维护了一个 AuthenticationProvider 数组,它们都可以用来进行认证,当前用户的凭证在某个 AuthenticationProvider 无法通过时就换下一个继续,直到全部都无法通过才算认证失败,而当有一个成功了则表示认证成功

AuthenticationProvider 接口的实现较多,有:

所以,认证流程,总体上是这样的:

DaoAuthenticationProvider

这个 DaoAuthenticationProvider 就是 AuthenticationProvider 的最常实现类,下面对其进行一些说明。

顾名思义,Dao 正是数据访问层的缩写,也暗示了这个身份认证器的实现思路。

按照我们最直观的思路,怎么去认证一个用户呢?

用户前台提交了用户名和密码,而数据库中保存了用户名和密码,认证便是负责比对同一个用户名,提交的密码和保存的密码是否相同便是了。

在 Spring Security 中。提交的用户名和密码,被封装成了 UsernamePasswordAuthenticationToken,而根据用户名加载用户的任务则是交给了 UserDetailsService

取得用户

在 DaoAuthenticationProvider 中,对应的根据用户名加载用户的方法便是 retrieveUser。

// 虽然有两个参数,但是 retrieveUser 只有第一个参数(username)起主要作用,返回一个 UserDetails。
protected final UserDetails retrieveUser(String username, UsernamePasswordAuthenticationToken authentication)
throws AuthenticationException {
prepareTimingAttackProtection();
try {
UserDetails loadedUser = this.getUserDetailsService().loadUserByUsername(username);
if (loadedUser == null) {
throw new InternalAuthenticationServiceException(
"UserDetailsService returned null, which is an interface contract violation");
}
return loadedUser;
}
catch (UsernameNotFoundException ex) {
mitigateAgainstTimingAttack(authentication);
throw ex;
}
catch (InternalAuthenticationServiceException ex) {
throw ex;
}
catch (Exception ex) {
throw new InternalAuthenticationServiceException(ex.getMessage(), ex);
}
}

比对密码

还需要完成 UsernamePasswordAuthenticationToken 和 UserDetails 密码的比对,这便是交给 additionalAuthenticationChecks 方法完成的(注意,这个方法是在父类 AbstractUserDetailsAuthenticationProvider 的 authenticate 方法中被调用的),如果这个 void 方法没有抛异常,则认为比对成功。

// 虽然这个方法启用了,但是好像执行的还是这个方法
@SuppressWarnings("deprecation")
protected void additionalAuthenticationChecks(UserDetails userDetails,
UsernamePasswordAuthenticationToken authentication)
throws AuthenticationException {
if (authentication.getCredentials() == null) {
logger.debug("Authentication failed: no credentials provided");
throw new BadCredentialsException(messages.getMessage(
"AbstractUserDetailsAuthenticationProvider.badCredentials",
"Bad credentials"));
}
String presentedPassword = authentication.getCredentials().toString();
if (!passwordEncoder.matches(presentedPassword, userDetails.getPassword())) {
logger.debug("Authentication failed: password does not match stored value");
throw new BadCredentialsException(messages.getMessage(
"AbstractUserDetailsAuthenticationProvider.badCredentials",
"Bad credentials"));
}
}

比对密码的过程,用到了 PasswordEncoder 和 SaltSource,密码加密和盐的概念相信不用我赘述了,它们为保障安全而设计,都是比较基础的概念。(这里的 PasswordEncoder 和 UserDetailsService 就是传入进来的)

注意,虽然注解显示这个 additionalAuthenticationChecks 方法弃用了,但是官方还是用这个比对密码的(如下执行时的断点)

总之就是 DaoAuthenticationProvider:它获取用户提交的用户名和密码,比对其正确性,如果正确,返回一个数据库中的用户信息(假设用户信息被保存在数据库中)。

UserDetails

上面不断提到了 UserDetails 这个接口,它代表了最详细的用户信息,这个接口涵盖了一些必要的用户信息字段,具体的实现类对它进行了扩展。

public interface UserDetails extends Serializable {

// 取得权限
Collection<? extends GrantedAuthority> getAuthorities();

String getPassword();

String getUsername();

boolean isAccountNonExpired();

boolean isAccountNonLocked();

boolean isCredentialsNonExpired();

boolean isEnabled();
}

它和 Authentication 接口很类似,比如它们都拥有 username,authorities,区分他们也是本文的重点内容之一。Authentication 的 getCredentials() 与 UserDetails 中的 getPassword() 需要被区分对待,前者是用户提交的密码凭证,后者是用户正确的密码,认证器其实就是对这两者的比对。

Authentication 中的 getAuthorities() 实际是由 UserDetails 的 getAuthorities() 传递而形成的。

还记得 Authentication 接口中的 getUserDetails() 方法吗?其中的 UserDetails 用户详细信息便是经过了 AuthenticationProvider 之后被填充的。

UserDetailsService

参考资料 Spring Security Custom Authentication - AuthenticationProvider vs UserDetailsService

public interface UserDetailsService {
UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;
}

UserDetailsService 和 AuthenticationProvider 两者的职责常常被人们搞混,UserDetailsService 它纯粹是一个用于用户数据的 DAO,除了向框架内的其他组件提供该数据之外,没有其他功能。特别是,它不对用户进行身份验证,这是由 AuthenticationManager 完成的。所以在多数情况下,如果需要自定义身份验证过程,直接实现 AuthenticationProvider 更有意义。

UserDetailsService 常见的实现类有

  • JdbcDaoImpl 从数据库加载用户
  • InMemoryUserDetailsManager 从内存中加载用户
  • 也可以自己实现 UserDetailsService,通常这更加灵活。

Reference

官方指引 官方文档 白话让你理解什么是oAuth2协议 最简单易懂的Spring Security 身份认证流程讲解 Spring Security零基础入门之一 SpringSecurity+JWT认证流程解析 How Spring Security Authentication works - Java Brains(这个视频教程讲的超级详细!!!整个流程的理解可以看它)